查看原文
其他

Bruce Eckel:再聊设计模式(篇一)

Bruce Eckel 中生代技术
2024-08-23

Bruce Eckel

读完需要

4分钟

速读仅需 2 分钟

布鲁斯 • 埃克尔(Bruce Eckel),C++ 标准委员会的创始成员之一,知名技术顾问,专注于编程语言和软件系统设计方面的研究,常活跃于世界各大顶级技术研讨会。
他自 1986 年以来,累计出版 Thinking in C++、Thinking in Java、On Java 等十余部经典计算机著作,曾多次荣获 Jolt 最佳图书奖(被誉为“软件业界的奥斯卡”),其代表作 Thinking in Java 被译为中文、日文、俄文、意大利文、波兰文、韩文等十几种语言,在世界范围内产生了广泛影响。

面向对象设计模式的演变历史参见《设计模式:可复用面向对象软件的基础》(以下简称《设计模式》)中的相关记载。

《设计模式》这本书中演示了 23 种针对特定问题类型的不同解决方案。本章将通过各种示例来讲解设计模式的基本概念。这会激发你阅读《设计模式》的兴趣,这本书已经成为面向对象程序员的宝典之一。

本章最后引入了一个关于设计演进过程的例子,模拟了垃圾分类场景,从最开始的设计开始,逐步改进逻辑和流程,最终演进到更好的设计。可以将该演进过程看作某种演进原型——将满足某个特定问题的方案发展为可满足一类问题的灵活方案。

1


   

设计模式的概念

一开始,你可以将模式视为一种解决特定类型问题的兼具巧妙和洞察力的方法。模式看起来就像很多人已经解决了问题的所有细节部分,然后想出了最通用和灵活的方案。你可能以前遇到过这个问题,并且解决了,但你的方案很可能并不如模式中的那么完善。

它们虽然被称为“设计模式”,但实际上与设计领域并无关联。模式似乎和传统的分析、设计和实现的思维方式不同。模式在程序中体现了完整的思想,因此它有时会出现在分析或高层设计的阶段。由于模式在代码中有着直接的实现,你可能并不希望它出现在低层设计或实现阶段,甚至是维护阶段之前。通常,在进入这些阶段之前,你不会意识到你需要一个特定的模式。

模式的基本概念也可以看作程序设计的基本概念:增加一层抽象。无论何时,如果你要抽象某个事物,实际上就是在隔离某个特定的细节,而最有说服力的背后动机之一就是:

会变化的事物不会变化的事物分开。

还有一种说法,一旦你发现程序的某个部分可能会由于某种原因而发生变化,抽象就可以防止这些变化引发整个代码中的其他变化。

通常,要实现优雅且易维护的设计,其中最困难的部分是如何发现我所说的变化的向量(the vector of change,“向量”指的是最大的变化率,而不是某个集合类 )。这意味着要找到系统中最重要的会变化的事物。换言之,是要找到最大的(改动)成本。一旦找到了变化的向量,你就掌握了构建设计的关键点。

因此设计模式的目标就是隔离代码中的变化。如果能从这个角度看待设计模式,那就应该能意识到本书中已经出现过很多设计模式了。举例来说,继承可以看成一种设计模式(尽管是由编译器实现的)。它使你可以在具有相同接口的对象(保持不变的事物)中表现出行为的差异(变化的事物)。组合也可以被认为是一种模式,因为它使你可以动态或静态地改变实现类的对象,并由此改变对象的行为方式。

你也已经看到了另一种出现在《设计模式》中的模式:迭代器(Java1.0 和 1.1 任性地称其为“枚举”,Java 2 集合则称其为“迭代器”)。这会在遍历并逐个选择元素时隐藏集合的特定实现。迭代器使你可以在无须关心某个序列的构造方式的情况下,实现对该序列所有元素的某种操作。由此你的代码可以用于任何实现了迭代器的集合。

虽然设计模式很有用,但有些人断言:

设计模式代表着语言的失败之处。

这个看法很重要。比如说,某模式只是在 C++中很合理,但在 Java 或其他语言中却可能并无必要存在。出于这个原因,不能只是因为一个模式出现在了《设计模式》中,就认为它在你的语言中很有用。

我发现“语言的失败之处”这个看法很有用,但我也认为这过于简单化了。如果你在试图解决某个特定问题,而所用的语言又并未直接支持你所用的技术,那么你可以争辩说这是语言的一个失败之处。但是你的这种特定技术实际真的会经常用到吗?也许这种平衡才正是正确的:如果使用这种技术会使你耗费更多精力,那么你对该技术的需求程度可能并不足以证明提供语言级支持是合理的。另一方面,如果没有语言级支持,日常使用这种技术可能会太麻烦,但如果有了语言级支持,你可能会改变你的编程方式(例如 Java 8 的流就达到了这种效果)。

2


   

单例模式

最简单的设计模式可能就是单例(singleton)了,这是一种仅会提供唯一一个对象实例的方法。下面考虑一个参数化的 Resource(资源):

// patterns/Resource.java

public interface Resource<T> {
  T get();
  void set(T x);
}

假设我们想让每个不同的 Resource都仅能有一个实例。一种方法是为每个 T 都创建一个自定义的单例 Resource 类:

// patterns/SingletonPattern.java

final class IntegerSingleton
  implements Resource<Integer> 
{
  private static IntegerSingleton value =
    new IntegerSingleton();
  private Integer i = Integer.valueOf(0);
  private IntegerSingleton() {
    System.out.println("IntegerSingleton()");
  }
  public static IntegerSingleton instance() {
    return value;
  }
  @Override public synchronized
  Integer get() 
return i; }
  @Override public synchronized
  void set(Integer x) 
{ i = x; }
}

public class SingletonPattern {
  public static <T> void show(Resource<T> r) {
    T val = r.get();
    System.out.println(val);
  }
  public static <T> void put(Resource<T> r, T val) {
    r.set(val);
  }
  public static void main(String[] args) {
    System.out.println("Inside main()");
    Resource<Integer> ir =
      IntegerSingleton.instance();
    Resource<Integer> ir2 =
      IntegerSingleton.instance();
    show(ir);
    put(ir2, Integer.valueOf(9));
    show(ir);
  }
}
/* 输出:
Inside main()
IntegerSingleton()
0
9
*/

为了保证所控制的类型仅有一个对象被创建,我们将 IntegerSingleton 的构造器设为私有的,因此它只能在类中可用。

因为 value 对象是静态的,所以它在调用方程序员首次调用静态方法 instance()时被创建,此时类被加载,并执行 value 的静态初始化。main()中 ir2 的创建不会再次引发构造器调用。

JVM 的工作方式使得静态初始化是线程安全的。为了达到完全的线程安全,IntegerSingleton 的 getter 和 setter 都是 synchronized(同步)的。这很重要,因为多个线程可以分别持有指向同一个共享 IntegerSingleton 对象的引用。正如本书第 5 章中所描述的,即使我们自己并没有在并发程序中使用该类,也必须考虑并发问题。

Java 还允许通过克隆来创建对象(参见本书第 2 章)。本例将类修饰为 final 的,以防止克隆。

IntegerSingleton 并没有显式的基类,因此它直接继承自 Object。clone()方法仍旧是 protected 的,因此无法调用(如果调用,则会引发编译器错误)。不过,如果是从某个以 public 权限重写了 clone()方法,并实现了 Cloneable 的类层次结构中继承,那么阻止克隆就要重写 clone()方法,并抛出本书第 2 章中描述过的 CloneNotSupportedException 异常(也可以重写 clone()方法,并简单地返回 this,但这是自欺欺人,因为虽然调用方程序员认为他们是在克隆对象,但实际上处理的仍然是原本的对象)。

从 show() 和 put()函数可以看出,我们可以向上转型为 Resource,并使用多态。

也可以用这种方法来创建一个有限的对象池,尽管这样就必须管理池中的对象。如果这是个问题,那么我们的解决方案可以检查进出的共享对象。

继承如 Resource这样的基类可能看起来是不必要的额外工作。我们难道不应该仅仅创建一个实现单例概念的纯泛型类吗?下面是实现办法:

// patterns/Single.java

@SuppressWarnings("unchecked")
public final class Single<T> {
  private static Object single;       // [1]
  public Single(T val) {
    if(single != null)
      throw new RuntimeException(
        "Attempt to reassign Single<" +
        val.getClass().getSimpleName() + ">"
      );
    single = val;
  }
  public T get() { return (T)single; }
}

在[1]处我们实际想声明的是:

private static T Single;

这样会导致编译器错误消息:“非静态类型变量 T 不能从静态上下文中引用(non-static type variable T cannot be referenced from a static context)。”静态和泛型在 Java 中基本上是无法和谐共存的,因此需要将 Single 定义为 Object,然后在调用 get()时对它进行转型。同样,不能将 get()定义为静态方法(如果尝试这么做的话,则会得到很多关于静态泛型方法的教训)。

首次创建 Single 对象的时候,构造器将 static single 的值作为构造器参数。同一个 T 的第二次构造器调用会导致编译器错误消息。下面是个基本的测试:

// patterns/TestSingle.java

public class TestSingle {
  public static void main(String[] args) {
    Single<String> ss = new Single<>("hello");
    System.out.println(ss.get());
    try {
      Single<String> ss2 = new Single<>("world");
    } catch(Exception e) {
      System.out.println(e.getMessage());
    }
  }
}
/* 输出:
hello
Attempt to reassign Single<String>
*/

用Single来创建一个Double单例对象,执行正常:

// patterns/SingletonPattern2.java

public class SingletonPattern2 {
  public static void main(String[] args) {
    Single<Double> pi =
      new Single<>(Double.valueOf(3.14159));
    Double x = pi.get();
    System.out.println(x);
  }
}
/* 输出:
3.14159
*/

我们无法修改Double的内容。但是如果所讨论的对象是可修改的,又会怎样呢?

// patterns/SingletonPattern3.java

class MyString {
  private String s;
  public MyString(String s) {
    this.s = s;
  }
  public synchronized
  void change(String s) 
{
    this.s = s;
  }
  @Override public synchronized
  String toString() 
{
    return s;
  }
}

public class SingletonPattern3 {
  public static void main(String[] args) {
    Single<MyString> x =
      new Single<>(new MyString("Hello"));
    System.out.println(x.get());
    x.get().change("World!");
    System.out.println(x.get());
  }
}
/* 输出:
Hello
World!
*/

如果Single管理着一个可修改的对象,在并发场景下便会导致竞态条件。在MyString中,change()和toString()都是synchronized的,以防止发生竞态条件。

3


   

设计模式的分类

《设计模式》一书中讨论了23种不同的设计模式,并根据不同的目标将它们分为以下3类,每一类都围绕着某个可能发生变化的方面展开。

创建类:即创建对象的方式。这通常涉及隔离对象创建的细节,以使代码不依赖于对象的类型,这样在增加新对象类型时就不必做任何修改。单例模式可归为创建类模式,本章稍后你还会看到工厂模式的例子。

结构类:即如何设计满足特定项目约束的对象。这类设计主要围绕着这些对象和其他对象间的关联方式,以保证系统的变化不会导致这些关联方式的变化。

行为类:指处理程序中特定类型操作的对象。这些对象封装了要执行的流程,例如解释某种语言,满足某个请求,在序列中移动(比如通过迭代器),或者实现某种算法。本章列举了观察者模式(Observer)访问者模式(Visitor)的相关示例。

《设计模式》中的23个模式各自都包含一节带有示例的内容,一般由C++实现,但有时用的是SmallTalk。本章不会把《设计模式》中的所有模式都复述一遍,因为该书自成一体,应该单独去学习(线上也有很多不错的资源,很多都是用Java讲解的)。相反,你会看到一些例子,它们会让你很好地领悟到模式究竟是什么,以及它们为什么很重要。

和设计模式打了多年交道之后,我开始觉得,模式本身使用的是基本的组织原则,而不是《设计模式》中描述的那些原则(而且比这些原则更基础)。这些原则基于实现的结构,从中我看到了模式间显著的相似性(比《设计模式》中展现出来的更多)。虽然我们通常会避免使用接口实现,但我发现根据这些结构原则来考虑模式,尤其是学习模式,往往会更容易。本章会试图基于模式自身的结构——而不是通过《设计模式》中的分类——来呈现模式。

4


   

模板方法

应用程序框架可以帮助我们,通过从提供的框架类中复用大部分代码,来创建新的应用程序,以及重写各种方法,以根据我们的需求自定义应用程序。

应用程序框架的重要概念之一是模板方法模式(Template Method),它通常是隐藏的,并通过调用各种基类方法来驱动该应用程序,我们可以重写这些基类方法来创建应用程序。

模板方法被定义在基类中,并且无法被改变。它有时是私有方法,但实际上通常是final的。它会调用其他的基类方法(我们会重写这些基类方法)来执行任务,但是通常只会作为初始化过程中的某个步骤被调用,调用方程序员不一定能够直接调用它。

// patterns/TemplateMethod.java
// 基本的模板方法模式
import java.util.stream.*;

abstract class ApplicationFramework {
  ApplicationFramework() {
    templateMethod();
  }
  abstract void customize1(int n);
  abstract void customize2(int n);
  // private意味着自动为final的:
  private void templateMethod() {
    IntStream.range(05).forEach(
      n -> { customize1(n); customize2(n); });
  }
}

// 创建一个新应用程序:
class MyApp extends ApplicationFramework {
  @Override void customize1(int n) {
    System.out.print("customize1 " + n);
  }
  @Override void customize2(int n) {
    System.out.println(" customize2 " + n);
  }
}

public class TemplateMethod {
  public static void main(String[] args) {
    new MyApp();
  }
}
/* 输出:
customize1 0 customize2 0
customize1 1 customize2 1
customize1 2 customize2 2
customize1 3 customize2 3
customize1 4 customize2 4
*/

基类构造器负责执行必要的初始化,然后启动“引擎”(即模板方法),运行应用程序(在GUI程序中,“引擎”指主要事件的循环)。调用方程序员只需简单地提供customize1()和customize2()的定义,“应用程序”就做好了运行的准备。(未完待续,下一章节:封装实现

本书特色

  • 查漏宝典:涵盖Java关键特性的设计原理和应用方法

  • 避坑指南:以产业实践的得失为鉴,指明Java开发者不可不知的设计陷阱

  • 经典普适:值得不同层次的Java开发者反复研读

  • 专家领读:4位一线业务专家、知名作译者帮你拆解书中难点,总结Java开发精要


值得一提的是,为了帮助新手加深理解,出版方邀请了4位从业10年以上知名作译者DDD 专家张逸、服务端专家梁桂钊、软件系统架构专家王前明、译者陈德伟)为本书录制【精讲视频】和【导读指南】,该视频已在B站和图灵社区发布,感兴趣的朋友可以去看看。



往期推荐

Bruce Eckel - 详解函数式编程(卷一)

Bruce Eckel - 详解函数式编程(卷二)

Bruce Eckel - 详解函数式编程(卷三)

聊聊 8 种架构模式

他教全世界程序员怎么写好代码,答案写在这里!

被滥用的“架构师”!


继续滑动看下一个
中生代技术
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存